Skip to content

feat: chroma key (greenscreen) for the render pipeline - #183

Merged
hm21 merged 3 commits into
stablefrom
feat/chroma-key
Jul 28, 2026
Merged

feat: chroma key (greenscreen) for the render pipeline#183
hm21 merged 3 commits into
stablefrom
feat/chroma-key

Conversation

@hm21

@hm21 hm21 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Removes a solid-colored background with a soft edge and spill suppression, and fills it with a color, an image, or — in a VideoComposition — the layer below, so a video can sit behind the screen.

// measured off the footage — works for any saturated screen hue
final key = await ChromaKey.autoDetect(clip, backgroundColor: Colors.black);

// or a video behind the screen
VideoRenderData(
  composition: VideoComposition(layers: [
    VideoLayer(clips: [VideoSegment(video: backdrop)]),
    VideoLayer(clips: [VideoSegment(video: clip)], chromaKey: const ChromaKey()),
  ]),
);

Settable on VideoRenderData, VideoLayer and VideoSegment, resolved per clip as segment → layer → global.

How it works

The whole key — matte alpha and despilled color — is a pure function of RGB. That is what lets Apple do it through a CIColorCube with varying, premultiplied alpha: no CIKernel, no Metal, neither of which this repo has today. Android needs a real shader, because SingleColorLut cannot produce alpha; ChromaKeyEffect is modelled on the existing VideoCompositionTransformation.

Both platforms run the same BT.601 chroma-distance formula, pinned by an identical golden table in the Kotlin and Swift unit tests — so a drift in either implementation fails a fast test instead of surfacing later as "the key looks different on iOS".

Why autoDetect

A constant key sits beside the recorded screen. The demo clip's screen is painted SMPTE green (0xFF00B140); the camera recorded 0xFF2A9D37 — a chroma distance of 0.12. That offset has to be paid for with a wider similarity, which is exactly the margin protecting the subject.

screen pixels sit at share
SMPTE constant 0.10–0.20 87 %
measured key 0.00–0.05 89 %

Same footage, roughly twice the headroom. Detection samples a border ring through the existing thumbnail pipeline, so it is pure Dart and touches no native code. ChromaKey.detect returns the raw measurement (color, spread, border coverage) for showing to the user, and throws ChromaKeyDetectionException when the border is not a clean screen.

greenScreen() / blueScreen() presets carry the measured values. Blue is not green with a different hue — it separates skin better (0.41–0.46 vs 0.38–0.42) but denim (0.19), blue eyes (0.20) and light blue shirts (0.21) all sit inside the green-tuned default, so the blue preset keys tighter and despills more gently.

Two pre-existing bugs fixed along the way

  • VideoCompositionTransformation squared each composition layer's alpha. It blended against an already-transparent framebuffer (dst.a = a² + 0·(1−a)). A no-op while every layer was opaque; load-bearing once a keyed layer arrives with a soft edge. Confirmed against Media3's own DefaultCompositorGlProgram, which uses glBlendFuncSeparate and does the one real blend.
  • EditorVideo.safeFilePath named its temp file from the millisecond alone, so sources resolved concurrently — as VideoRenderData.toAsyncMap does — could overwrite each other.

Not covered

  • No time ranges on a key (unlike ColorFilter); split the clip instead.
  • Ignored inside an overlap ClipTransition when the two clips carry different keys — the blend is pre-rendered from raw sources and is emitted unkeyed with a warning.
  • No matte choke/erode, no light wrap.
  • H.264/HEVC carry no alpha, so on the single-track videoSegments path a key without a background is flattened to black. There is a debug assert pointing at composition.

Verification

Dart unit 333 (was 313)
Kotlin unit 13 new, suite green
Swift unit 18 green
chroma_key_test 14/14 on macOS and a Galaxy S25
Regression (both platforms) video_render, composition, layer_animation, image_layer, render_pixel — all green

The example app gets its own Chroma Key page with a "measure the screen" button, a color/image/video background switch and sliders for all three parameters. The demo asset is a real studio green-screen clip, cropped to the screen area and downscaled to 330 KB.

Separately noted while building the test fixtures, not touched here: two renderStopMotion outputs do not concatenate (2s + 2s comes out as 2.03s on macOS). Unrelated to this change, but it looks like a real bug.

hm21 added 3 commits July 28, 2026 17:48
Removes a solid-colored background with a soft edge and spill suppression,
and fills it with a color, an image, or — in a VideoComposition — the layer
below, so a video can sit behind the screen. Settable on VideoRenderData,
VideoLayer and VideoSegment, resolved per clip as segment -> layer -> global.

The whole key (matte alpha and despilled color) is a pure function of RGB,
which is what lets Apple do it through a CIColorCube with varying,
premultiplied alpha — no CIKernel and no Metal, neither of which the repo has
today. Android needs a real shader, because SingleColorLut cannot produce
alpha; ChromaKeyEffect is modelled on the existing VideoCompositionTransformation.
Both platforms run the same BT.601 chroma-distance formula, pinned by an
identical golden table in the Kotlin and Swift unit tests so a drift in either
fails fast instead of surfacing as "it looks different on iOS".

ChromaKey.autoDetect measures the key color and similarity off the footage
rather than guessing. A constant sits beside the *recorded* screen — a studio
screen painted SMPTE green recorded as 0xFF2A9D37, a chroma distance of 0.12 —
and that offset has to be paid for with a wider similarity, which is the margin
that protects the subject. Measured on the demo clip: against the constant the
screen sat 0.10-0.20 away and needed similarity 0.20; against its own measured
color it sat within 0.05 and 0.10 sufficed. Detection runs through the existing
thumbnail pipeline, so it is pure Dart and touches no native code.

greenScreen()/blueScreen() presets carry the measured values. Blue is not green
with a different hue: it separates skin better (0.41-0.46 vs 0.38-0.42) but
denim (0.19), blue eyes (0.20) and light blue shirts (0.21) all sit inside the
green-tuned default, so the blue preset keys tighter and despills more gently.

Two pre-existing bugs surfaced and are fixed here:

- VideoCompositionTransformation blended each composition layer against an
  already-transparent framebuffer, squaring its alpha. A no-op while every
  layer was opaque; load-bearing once a keyed layer arrives with a soft edge.
- EditorVideo.safeFilePath named its temp file from the millisecond alone, so
  sources resolved concurrently (as VideoRenderData.toAsyncMap does) could
  overwrite each other.

Verified end to end on macOS and a Galaxy S25: 14 chroma-key integration tests
plus the existing render, composition, image-layer, layer-animation and
render-pixel suites.
- Detector: the coverage guard was unreachable. `coverage` was measured
  against a band derived from the distances' own 99th percentile, so it
  came out ~0.99 for every possible frame and `detect` never rejected a
  border that was not a clean screen. Use a constant band, and measure
  `spread` over the covered pixels only so a subject at the border cannot
  widen the key.
- Darwin: the chroma-key window end-clamp keyed every frame after the
  last keyed clip. Windows are emitted per keyed clip, so the array is
  sparse; bound the clamp to one frame.
- Android: the HDR pre-transcode only rewrote `videoClips`, so an HDR
  clip on a composition layer reached the ES 2.0 shader and hard-failed
  the export. Retarget the composition layers' clips too.
- Both: a transition blend with mismatched keys was logged as "emitted
  unkeyed" but `null` means inherit, so the global key was applied to it
  anyway. Add `suppressChromaKey` and compare the resolved keys, which
  also drops the spurious warning when both sides resolve to the same key.
- Android: the background image was sampled with the frame's texture
  coordinate, where t=0 is the bottom, while GLUtils.texImage2D uploads
  row 0 at t=0 — so it rendered upside down. Flip the background lookup.
- Both: `backgroundColor` alpha was honored on Darwin and dropped on
  Android. Ignore it on both and assert the color is opaque.
- Both: a background image that will not decode fell back to transparent
  on Android and black on Darwin. Fill with opaque black on both.
- Android: downscale a background bitmap to GL_MAX_TEXTURE_SIZE instead
  of throwing, recycle the placeholder, and release the program even when
  deleting the texture throws.
- Darwin: identify a cached background by content, not byte length, and
  run the key before the layer transform so it sees unresampled pixels.
- `ChromaKey.copyWith` could not switch between the two background types
  and could not clear them; add `removeBackground`.
- `ChromaKey.detect` never disposed its codecs or images.
- The H.264-alpha assert only inspected the global key, not per-segment.
- Correct the docs claiming the key color's brightness is ignored.
@hm21
hm21 merged commit 1f4d043 into stable Jul 28, 2026
1 check passed
@hm21
hm21 deleted the feat/chroma-key branch July 28, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant